diff --git a/Sources/DHModels/DifficultyBudget.swift b/Sources/DHModels/DifficultyBudget.swift index 26fda18..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,12 +125,19 @@ 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``, 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, 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) diff --git a/Tests/DHModelsTests/ModelTests.swift b/Tests/DHModelsTests/ModelTests.swift index 730bad1..df27312 100644 --- a/Tests/DHModelsTests/ModelTests.swift +++ b/Tests/DHModelsTests/ModelTests.swift @@ -385,37 +385,87 @@ 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) + } + + @Test func ratingReturnsNilForNegativePlayers() { + #expect(DifficultyBudget.rating(adversaryTypes: [], playerCount: -1) == nil) + } + + // MARK: Rating.category / displayName + + @Test func categoryTooEasy() { + // lower boundary (4) and a value well above + #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 categoryOnBudget() { + #expect(DifficultyBudget.Rating(budget: 14, cost: 14, remaining: 0).category == .onBudget) + } + + @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 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 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 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 @Test func adjustmentForMultipleSolos() {