Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions Sources/DHModels/DifficultyBudget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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)
Expand Down
82 changes: 66 additions & 16 deletions Tests/DHModelsTests/ModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading