From df0cc006ba478fba9158cf475c38247f400ca919 Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 25 Apr 2026 10:53:25 -0700 Subject: [PATCH 1/2] Add label to DifficultyBudget.Rating; return Rating? for zero player count Moves the difficulty label switch from DifficultyAssessorView into the model so any view gets the canonical label without a local switch. Also guards against a zero-player party, which produces a meaningless budget, by returning nil from the rating factory. Closes #24 --- Sources/DHModels/DifficultyBudget.swift | 23 +++++++- Tests/DHModelsTests/ModelTests.swift | 72 +++++++++++++++++++------ 2 files changed, 77 insertions(+), 18 deletions(-) diff --git a/Sources/DHModels/DifficultyBudget.swift b/Sources/DHModels/DifficultyBudget.swift index 26fda18..deab74e 100644 --- a/Sources/DHModels/DifficultyBudget.swift +++ b/Sources/DHModels/DifficultyBudget.swift @@ -86,12 +86,13 @@ nonisolated public enum DifficultyBudget { /// - adversaryTypes: The types of all adversaries in the encounter. /// - playerCount: Number of player characters. /// - budgetAdjustment: Manual adjustment to the base budget (from ``Adjustment`` point values). - /// - Returns: A ``Rating`` with budget, cost, and remaining points. + /// - Returns: A ``Rating`` with budget, cost, and remaining points, or `nil` when `playerCount` is zero. public static func rating( adversaryTypes: [AdversaryType], playerCount: Int, budgetAdjustment: Int = 0 - ) -> Rating { + ) -> Rating? { + guard playerCount > 0 else { return nil } let budget = baseBudget(playerCount: playerCount) + budgetAdjustment let cost = totalCost(for: adversaryTypes) return Rating(budget: budget, cost: cost, remaining: budget - cost) @@ -231,3 +232,21 @@ nonisolated public enum DifficultyBudget { return result } } + +extension DifficultyBudget.Rating { + /// Human-readable difficulty description based on `remaining` (budget − cost). + /// + /// Positive values indicate unspent budget; negative values indicate the + /// roster exceeds the recommended budget. Thresholds match those in + /// `DifficultyAssessorView` in the Encounter app. + public var label: String { + switch remaining { + case 4...: return "Too Easy" + case 1...3: return "Well Matched" + case 0: return "On Budget" + case -3...(-1): return "Challenging" + case -6...(-4): return "Dangerous" + default: return "Likely TPK" + } + } +} diff --git a/Tests/DHModelsTests/ModelTests.swift b/Tests/DHModelsTests/ModelTests.swift index 730bad1..742a9db 100644 --- a/Tests/DHModelsTests/ModelTests.swift +++ b/Tests/DHModelsTests/ModelTests.swift @@ -385,37 +385,77 @@ struct DifficultyBudgetTests { // MARK: Rating - @Test func ratingWithinBudgetIsBalanced() { - let rating = DifficultyBudget.rating( - adversaryTypes: [.standard, .standard, .minion], - playerCount: 4 - ) + @Test func ratingWithinBudgetIsBalanced() throws { + let rating = try #require( + DifficultyBudget.rating( + adversaryTypes: [.standard, .standard, .minion], + playerCount: 4 + )) #expect(rating.cost == 5) #expect(rating.budget == 14) #expect(rating.remaining == 9) } - @Test func ratingOverBudgetShowsNegativeRemaining() { - let rating = DifficultyBudget.rating( - adversaryTypes: [.solo, .solo, .bruiser], - playerCount: 3 - ) + @Test func ratingOverBudgetShowsNegativeRemaining() throws { + let rating = try #require( + DifficultyBudget.rating( + adversaryTypes: [.solo, .solo, .bruiser], + playerCount: 3 + )) #expect(rating.cost == 14) #expect(rating.budget == 11) #expect(rating.remaining == -3) } - @Test func ratingWithBudgetAdjustment() { - let rating = DifficultyBudget.rating( - adversaryTypes: [.standard], - playerCount: 4, - budgetAdjustment: -2 - ) + @Test func ratingWithBudgetAdjustment() throws { + let rating = try #require( + DifficultyBudget.rating( + adversaryTypes: [.standard], + playerCount: 4, + budgetAdjustment: -2 + )) #expect(rating.budget == 12) #expect(rating.cost == 2) #expect(rating.remaining == 10) } + @Test func ratingReturnsNilForZeroPlayers() { + #expect(DifficultyBudget.rating(adversaryTypes: [.standard], playerCount: 0) == nil) + } + + // MARK: Rating.label + + @Test func labelTooEasy() { + // lower boundary (4) and a value well above + #expect(DifficultyBudget.Rating(budget: 14, cost: 10, remaining: 4).label == "Too Easy") + #expect(DifficultyBudget.Rating(budget: 14, cost: 4, remaining: 10).label == "Too Easy") + } + + @Test func labelWellMatched() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 11, remaining: 3).label == "Well Matched") + #expect(DifficultyBudget.Rating(budget: 14, cost: 13, remaining: 1).label == "Well Matched") + } + + @Test func labelOnBudget() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 14, remaining: 0).label == "On Budget") + } + + @Test func labelChallenging() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 15, remaining: -1).label == "Challenging") + #expect(DifficultyBudget.Rating(budget: 14, cost: 17, remaining: -3).label == "Challenging") + } + + @Test func labelDangerous() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 18, remaining: -4).label == "Dangerous") + #expect(DifficultyBudget.Rating(budget: 14, cost: 20, remaining: -6).label == "Dangerous") + } + + @Test func labelLikelyTPK() { + // lower boundary (-7) and a value well below + #expect(DifficultyBudget.Rating(budget: 14, cost: 21, remaining: -7).label == "Likely TPK") + #expect(DifficultyBudget.Rating(budget: 14, cost: 24, remaining: -10).label == "Likely TPK") + } + // MARK: Adjustment Suggestions @Test func adjustmentForMultipleSolos() { From 70297bb6e7eb8acc762c55e22a25877e0485ee2b Mon Sep 17 00:00:00 2001 From: Fritz Date: Sat, 25 Apr 2026 11:05:55 -0700 Subject: [PATCH 2/2] Refine Rating API: Category enum, displayName, guard negative playerCount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nest `Category: String, Sendable` enum inside `Rating` with six cases (tooEasy, wellMatched, onBudget, challenging, dangerous, likelyTPK) and a `displayName` property that returns the raw-value string - Replace `label: String` (extension) with `category: Category` and `displayName: String` as struct members; drop the trailing extension - Extend nil guard in `rating(...)` to cover negative playerCount - Update tests: rename labelXxx → categoryXxx, compare against enum cases instead of string literals, add negative-playerCount nil test and displayName round-trip test --- Sources/DHModels/DifficultyBudget.swift | 65 +++++++++++++++++-------- Tests/DHModelsTests/ModelTests.swift | 48 ++++++++++-------- 2 files changed, 75 insertions(+), 38 deletions(-) diff --git a/Sources/DHModels/DifficultyBudget.swift b/Sources/DHModels/DifficultyBudget.swift index deab74e..f67eecb 100644 --- a/Sources/DHModels/DifficultyBudget.swift +++ b/Sources/DHModels/DifficultyBudget.swift @@ -66,6 +66,20 @@ nonisolated public enum DifficultyBudget { /// A snapshot of the encounter's difficulty budget analysis. nonisolated public struct Rating: Sendable, Equatable, Hashable { + + /// Categorical difficulty level derived from ``remaining``. + public enum Category: String, Sendable { + case tooEasy = "Too Easy" + case wellMatched = "Well Matched" + case onBudget = "On Budget" + case challenging = "Challenging" + case dangerous = "Dangerous" + case likelyTPK = "Likely TPK" + + /// The human-readable name for this category. + public var displayName: String { rawValue } + } + /// Total Battle Points available (base budget + adjustment). public let budget: Int /// Total Battle Points spent on the adversary roster. @@ -78,6 +92,31 @@ nonisolated public enum DifficultyBudget { self.cost = cost self.remaining = remaining } + + /// The difficulty category for this rating, derived from `remaining` (budget − cost). + /// + /// | `remaining` | Category | + /// |-------------|----------------| + /// | ≥ 4 | `.tooEasy` | + /// | 1...3 | `.wellMatched` | + /// | 0 | `.onBudget` | + /// | −1...−3 | `.challenging` | + /// | −4...−6 | `.dangerous` | + /// | ≤ −7 | `.likelyTPK` | + public var category: Category { + switch remaining { + case 4...: return .tooEasy + case 1...3: return .wellMatched + case 0: return .onBudget + case -3...(-1): return .challenging + case -6...(-4): return .dangerous + default: return .likelyTPK + } + } + + /// Human-readable difficulty label for this rating. + /// Equivalent to `category.displayName`. + public var displayName: String { category.displayName } } /// Compute the difficulty rating for an adversary roster against a player count. @@ -86,7 +125,13 @@ nonisolated public enum DifficultyBudget { /// - adversaryTypes: The types of all adversaries in the encounter. /// - playerCount: Number of player characters. /// - budgetAdjustment: Manual adjustment to the base budget (from ``Adjustment`` point values). - /// - Returns: A ``Rating`` with budget, cost, and remaining points, or `nil` when `playerCount` is zero. + /// - Returns: A ``Rating``, or `nil` if `playerCount` is 0 or negative. + /// Callers should handle `nil` to indicate that no meaningful difficulty + /// can be computed without a party. + /// + /// - Note: Changing this return type from `Rating` to `Rating?` is a + /// source-breaking change. A semver major version bump is required on + /// the next release. public static func rating( adversaryTypes: [AdversaryType], playerCount: Int, @@ -232,21 +277,3 @@ nonisolated public enum DifficultyBudget { return result } } - -extension DifficultyBudget.Rating { - /// Human-readable difficulty description based on `remaining` (budget − cost). - /// - /// Positive values indicate unspent budget; negative values indicate the - /// roster exceeds the recommended budget. Thresholds match those in - /// `DifficultyAssessorView` in the Encounter app. - public var label: String { - switch remaining { - case 4...: return "Too Easy" - case 1...3: return "Well Matched" - case 0: return "On Budget" - case -3...(-1): return "Challenging" - case -6...(-4): return "Dangerous" - default: return "Likely TPK" - } - } -} diff --git a/Tests/DHModelsTests/ModelTests.swift b/Tests/DHModelsTests/ModelTests.swift index 742a9db..df27312 100644 --- a/Tests/DHModelsTests/ModelTests.swift +++ b/Tests/DHModelsTests/ModelTests.swift @@ -423,37 +423,47 @@ struct DifficultyBudgetTests { #expect(DifficultyBudget.rating(adversaryTypes: [.standard], playerCount: 0) == nil) } - // MARK: Rating.label + @Test func ratingReturnsNilForNegativePlayers() { + #expect(DifficultyBudget.rating(adversaryTypes: [], playerCount: -1) == nil) + } + + // MARK: Rating.category / displayName - @Test func labelTooEasy() { + @Test func categoryTooEasy() { // lower boundary (4) and a value well above - #expect(DifficultyBudget.Rating(budget: 14, cost: 10, remaining: 4).label == "Too Easy") - #expect(DifficultyBudget.Rating(budget: 14, cost: 4, remaining: 10).label == "Too Easy") + #expect(DifficultyBudget.Rating(budget: 14, cost: 10, remaining: 4).category == .tooEasy) + #expect(DifficultyBudget.Rating(budget: 14, cost: 4, remaining: 10).category == .tooEasy) + } + + @Test func categoryWellMatched() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 11, remaining: 3).category == .wellMatched) + #expect(DifficultyBudget.Rating(budget: 14, cost: 13, remaining: 1).category == .wellMatched) } - @Test func labelWellMatched() { - #expect(DifficultyBudget.Rating(budget: 14, cost: 11, remaining: 3).label == "Well Matched") - #expect(DifficultyBudget.Rating(budget: 14, cost: 13, remaining: 1).label == "Well Matched") + @Test func categoryOnBudget() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 14, remaining: 0).category == .onBudget) } - @Test func labelOnBudget() { - #expect(DifficultyBudget.Rating(budget: 14, cost: 14, remaining: 0).label == "On Budget") + @Test func categoryChallenging() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 15, remaining: -1).category == .challenging) + #expect(DifficultyBudget.Rating(budget: 14, cost: 17, remaining: -3).category == .challenging) } - @Test func labelChallenging() { - #expect(DifficultyBudget.Rating(budget: 14, cost: 15, remaining: -1).label == "Challenging") - #expect(DifficultyBudget.Rating(budget: 14, cost: 17, remaining: -3).label == "Challenging") + @Test func categoryDangerous() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 18, remaining: -4).category == .dangerous) + #expect(DifficultyBudget.Rating(budget: 14, cost: 20, remaining: -6).category == .dangerous) } - @Test func labelDangerous() { - #expect(DifficultyBudget.Rating(budget: 14, cost: 18, remaining: -4).label == "Dangerous") - #expect(DifficultyBudget.Rating(budget: 14, cost: 20, remaining: -6).label == "Dangerous") + @Test func categoryLikelyTPK() { + // boundary (-7) and a value well below + #expect(DifficultyBudget.Rating(budget: 14, cost: 21, remaining: -7).category == .likelyTPK) + #expect(DifficultyBudget.Rating(budget: 14, cost: 24, remaining: -10).category == .likelyTPK) } - @Test func labelLikelyTPK() { - // lower boundary (-7) and a value well below - #expect(DifficultyBudget.Rating(budget: 14, cost: 21, remaining: -7).label == "Likely TPK") - #expect(DifficultyBudget.Rating(budget: 14, cost: 24, remaining: -10).label == "Likely TPK") + @Test func displayNameMatchesCategoryRawValue() { + let rating = DifficultyBudget.Rating(budget: 14, cost: 14, remaining: 0) + #expect(rating.displayName == rating.category.rawValue) + #expect(rating.displayName == "On Budget") } // MARK: Adjustment Suggestions