Skip to content

Add label: String to DifficultyBudget.Rating #24

@gwillish

Description

@gwillish

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:

  1. 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.
  2. 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

  • DifficultyBudget.rating(adversaryTypes:playerCount:budgetAdjustment:) returns Rating? and returns nil when playerCount == 0.
  • DifficultyBudget.Rating gains a public var label: String property. (PR Add label to DifficultyBudget.Rating; guard zero player count in rating factory #25 delivers displayName: String and category: Category instead — naming diverged from spec; label does not exist. Needs discussion or a rename.)
  • The six labels are "Too Easy", "Well Matched", "On Budget", "Challenging", "Dangerous", "Likely TPK".
  • Thresholds match those currently in DifficultyAssessorView exactly.
  • The Encounter app's DifficultyAssessorView and EncounterLibraryRow both switch to rating.label — no local switch statements remain. (Out of scope for this DHModels PR; follow-up tracked in Encounter #91. EncounterLibraryRow also does not exist yet.)
  • Unit tests cover all six label branches (edge values at each boundary) and the playerCount == 0 nil case.

Context

Deferred from Encounter issue #91 while adding the compact difficulty summary to EncounterLibraryRow.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions