Problem
DifficultyBudget.Rating carries budget, cost, and remaining but has no way to describe itself in human-readable terms. The mapping from remaining (budget surplus/deficit) to a UI label is currently a 6-case switch that lives inside DifficultyAssessorView in the Encounter app (see Encounter #91). That switch is about to be copy-pasted into a second view (EncounterLibraryRow) to show a compact difficulty summary in the encounter library list.
Duplicating threshold logic across views is a maintenance hazard: the two copies can silently drift — someone adjusts a boundary in one view and the library row shows a different label for the same rating. The fix belongs in the model, not the UI.
Proposed solution
Add a label: String computed property directly to DifficultyBudget.Rating. Any view that holds a Rating gets the label for free; no local switch needed.
extension DifficultyBudget.Rating {
/// Human-readable description of the encounter's difficulty relative to budget.
///
/// Based on `remaining` (budget − cost). Positive values indicate slack;
/// negative values indicate the roster exceeds the recommended budget.
public var label: String {
switch remaining {
case 4...: return "Too Easy"
case 2...3: return "Well Matched"
case 0...1: return "On Budget"
case -2...(-1): return "Challenging"
case -4...(-3): return "Dangerous"
default: return "Likely TPK"
}
}
}
Note: The exact threshold boundaries above are a sketch. Whoever picks this up should transcribe the actual boundary values directly from the switch in DifficultyAssessorView in the Encounter app so they match exactly. The goal is a single authoritative definition, not a re-derivation.
Zero-player edge case
rating(adversaryTypes:playerCount:budgetAdjustment:) currently accepts any Int for playerCount, including zero. A zero-player party produces a budget of (3 × 0) + 2 = 2, which is arithmetically valid but meaningless — there are no characters to fight the encounter.
The tempting fix is to return "No party" (or similar) from label when the budget implies no players. That would be wrong for two reasons:
Rating doesn't carry playerCount. By the time execution reaches label, that information is gone. A "No party" branch would have to infer the edge case indirectly (e.g. budget == 2) — a fragile heuristic that breaks the moment any budget adjustment is applied.
- A sentinel string is a presentation decision baked into the model. Callers can't distinguish a real difficulty label from a "not applicable" fallback without string-parsing. A typed absence (
nil) expresses that distinction cleanly.
The right fix is to make the factory method return Rating?, returning nil when playerCount == 0:
public static func rating(
adversaryTypes: [AdversaryType],
playerCount: Int,
budgetAdjustment: Int = 0
) -> 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)
}
Each view then decides how to render nil — a greyed-out placeholder, an empty state, or simply nothing. That decision stays in the UI where it belongs.
This return-type change should ship in the same PR as label so callers are updated once rather than twice.
Acceptance criteria
Context
Deferred from Encounter issue #91 while adding the compact difficulty summary to EncounterLibraryRow.
Problem
DifficultyBudget.Ratingcarriesbudget,cost, andremainingbut has no way to describe itself in human-readable terms. The mapping fromremaining(budget surplus/deficit) to a UI label is currently a 6-caseswitchthat lives insideDifficultyAssessorViewin the Encounter app (see Encounter #91). That switch is about to be copy-pasted into a second view (EncounterLibraryRow) to show a compact difficulty summary in the encounter library list.Duplicating threshold logic across views is a maintenance hazard: the two copies can silently drift — someone adjusts a boundary in one view and the library row shows a different label for the same rating. The fix belongs in the model, not the UI.
Proposed solution
Add a
label: Stringcomputed property directly toDifficultyBudget.Rating. Any view that holds aRatinggets the label for free; no local switch needed.Zero-player edge case
rating(adversaryTypes:playerCount:budgetAdjustment:)currently accepts anyIntforplayerCount, including zero. A zero-player party produces a budget of(3 × 0) + 2 = 2, which is arithmetically valid but meaningless — there are no characters to fight the encounter.The tempting fix is to return
"No party"(or similar) fromlabelwhen the budget implies no players. That would be wrong for two reasons:Ratingdoesn't carryplayerCount. By the time execution reacheslabel, that information is gone. A "No party" branch would have to infer the edge case indirectly (e.g.budget == 2) — a fragile heuristic that breaks the moment any budget adjustment is applied.nil) expresses that distinction cleanly.The right fix is to make the factory method return
Rating?, returningnilwhenplayerCount == 0:Each view then decides how to render
nil— a greyed-out placeholder, an empty state, or simply nothing. That decision stays in the UI where it belongs.This return-type change should ship in the same PR as
labelso callers are updated once rather than twice.Acceptance criteria
DifficultyBudget.rating(adversaryTypes:playerCount:budgetAdjustment:)returnsRating?and returnsnilwhenplayerCount == 0.DifficultyBudget.Ratinggains apublic var label: Stringproperty. (PR Add label to DifficultyBudget.Rating; guard zero player count in rating factory #25 deliversdisplayName: Stringandcategory: Categoryinstead — naming diverged from spec;labeldoes not exist. Needs discussion or a rename.)DifficultyAssessorViewexactly.DifficultyAssessorViewandEncounterLibraryRowboth switch torating.label— no local switch statements remain. (Out of scope for this DHModels PR; follow-up tracked in Encounter #91.EncounterLibraryRowalso does not exist yet.)playerCount == 0nil case.Context
Deferred from Encounter issue #91 while adding the compact difficulty summary to
EncounterLibraryRow.