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
77 changes: 71 additions & 6 deletions Sources/DHModels/DifficultyBudget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,53 @@ nonisolated public enum DifficultyBudget {
return Rating(budget: budget, cost: cost, remaining: budget - cost)
}

// MARK: - Tier Utilities

/// Returns the Daggerheart tier (1–4) for a given character level (1–10).
///
/// | Level | Tier |
/// |-------|------|
/// | 1 | 1 |
/// | 2–4 | 2 |
/// | 5–7 | 3 |
/// | 8–10 | 4 |
///
/// Per SRD "Building Balanced Encounters".
public static func tier(forLevel level: Int) -> Int {
switch level {
case ...1: return 1
case 2...4: return 2
case 5...7: return 3
default: return 4
}
}

/// Returns the party tier derived from the median character level.
///
/// The median level is computed, then rounded up (ceiling) before mapping
/// to tier. When the median straddles a tier boundary the party is treated
/// as the higher tier — giving slightly more adversary budget latitude.
/// Empty input returns Tier 1 as a safe default.
///
/// Examples:
/// - `[1]` → median 1 → T1
/// - `[2, 3, 4, 5]` → median 3.5 → ceil 4 → T2
/// - `[4, 5]` → median 4.5 → ceil 5 → T3
/// - `[7, 8, 9]` → median 8 → T4
public static func partyTier(levels: [Int]) -> Int {
guard !levels.isEmpty else { return 1 }
let sorted = levels.sorted()
let count = sorted.count
let medianLevel: Double
if count % 2 == 1 {
medianLevel = Double(sorted[count / 2])
} else {
medianLevel = (Double(sorted[count / 2 - 1]) + Double(sorted[count / 2])) / 2.0
}
let ceiledLevel = Int(ceil(medianLevel))
return tier(forLevel: ceiledLevel)
}

// MARK: - Adjustment Suggestions

/// Predefined budget adjustments from the SRD.
Expand Down Expand Up @@ -141,14 +188,23 @@ nonisolated public enum DifficultyBudget {

/// Determine which SRD adjustments apply automatically based on the roster.
///
/// Only returns adjustments that can be mechanically detected:
/// - `.multipleSolos` if 2+ Solo types
/// - `.noBigThreats` if no Bruiser, Horde, Leader, or Solo types
/// Mechanically detected adjustments:
/// - `.multipleSolos` — 2 or more Solo types in the roster.
/// - `.noBigThreats` — no Bruiser, Horde, Leader, or Solo types, and the roster is non-empty.
/// - `.lowerTierAdversary` — `partyTier` is non-nil and at least one adversary has a
/// known tier (non-zero entry in `adversaryTiers`) strictly less than `partyTier`.
///
/// GM-discretionary adjustments (easier/harder fight, boosted damage) are never
/// auto-detected and must be toggled manually.
///
/// GM-discretionary adjustments (easier/harder fight, boosted damage,
/// lower tier) must be toggled manually in the UI.
/// - Parameters:
/// - adversaryTypes: The types of all adversaries in the encounter.
/// - adversaryTiers: Tiers parallel to `adversaryTypes`; use `0` for unknown.
/// - partyTier: The party's derived tier; pass `nil` to skip lower-tier detection.
public static func suggestedAdjustments(
adversaryTypes: [AdversaryType]
adversaryTypes: [AdversaryType],
adversaryTiers: [Int] = [],
partyTier: Int? = nil
) -> Set<Adjustment> {
var result: Set<Adjustment> = []

Expand All @@ -163,6 +219,15 @@ nonisolated public enum DifficultyBudget {
result.insert(.noBigThreats)
}

if let pt = partyTier {
let hasLowerTier = zip(adversaryTypes, adversaryTiers).contains { _, tier in
tier > 0 && tier < pt
}
if hasLowerTier {
result.insert(.lowerTierAdversary)
}
}

return result
}
}
17 changes: 17 additions & 0 deletions Sources/DHModels/EncounterDefinition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import Foundation
nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable, Identifiable {
public let id: UUID
public let name: String
/// The player character's level (1–10). Defaults to `1` when absent in JSON.
public let level: Int
public let maxHP: Int
public let maxStress: Int
public let evasion: Int
Expand All @@ -41,6 +43,7 @@ nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable,
public init(
id: UUID = UUID(),
name: String,
level: Int = 1,
maxHP: Int,
maxStress: Int,
evasion: Int,
Expand All @@ -50,13 +53,27 @@ nonisolated public struct PlayerConfig: Codable, Sendable, Equatable, Hashable,
) {
self.id = id
self.name = name
self.level = level
self.maxHP = maxHP
self.maxStress = maxStress
self.evasion = evasion
self.thresholdMajor = thresholdMajor
self.thresholdSevere = thresholdSevere
self.armorSlots = armorSlots
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
level = try container.decodeIfPresent(Int.self, forKey: .level) ?? 1
maxHP = try container.decode(Int.self, forKey: .maxHP)
maxStress = try container.decode(Int.self, forKey: .maxStress)
evasion = try container.decode(Int.self, forKey: .evasion)
thresholdMajor = try container.decode(Int.self, forKey: .thresholdMajor)
thresholdSevere = try container.decode(Int.self, forKey: .thresholdSevere)
armorSlots = try container.decode(Int.self, forKey: .armorSlots)
}
}

// MARK: - EncounterDefinition
Expand Down
19 changes: 19 additions & 0 deletions Sources/DHModels/Player.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
public let id: UUID
/// The player character's name.
public var name: String
/// The player character's level (1–10).
public var level: Int
/// Maximum hit points for this character.
public var maxHP: Int
/// Maximum stress for this character.
Expand All @@ -44,6 +46,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
/// - Parameters:
/// - id: Stable identifier; defaults to a new UUID.
/// - name: The player character's name.
/// - level: The character's level (1–10); defaults to `1`.
/// - maxHP: Maximum hit points.
/// - maxStress: Maximum stress.
/// - evasion: The DC for rolls made against this PC.
Expand All @@ -53,6 +56,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
public init(
id: UUID = UUID(),
name: String,
level: Int = 1,
maxHP: Int,
maxStress: Int,
evasion: Int,
Expand All @@ -62,6 +66,7 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
) {
self.id = id
self.name = name
self.level = level
self.maxHP = maxHP
self.maxStress = maxStress
self.evasion = evasion
Expand All @@ -70,12 +75,26 @@ nonisolated public struct Player: Codable, Sendable, Equatable, Hashable, Identi
self.armorSlots = armorSlots
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
level = try container.decodeIfPresent(Int.self, forKey: .level) ?? 1
maxHP = try container.decode(Int.self, forKey: .maxHP)
maxStress = try container.decode(Int.self, forKey: .maxStress)
evasion = try container.decode(Int.self, forKey: .evasion)
thresholdMajor = try container.decode(Int.self, forKey: .thresholdMajor)
thresholdSevere = try container.decode(Int.self, forKey: .thresholdSevere)
armorSlots = try container.decode(Int.self, forKey: .armorSlots)
}

/// Snapshots this player's current stats into a ``PlayerConfig`` for use
/// in an ``EncounterDefinition`` or session creation.
public func asConfig() -> PlayerConfig {
PlayerConfig(
id: id,
name: name,
level: level,
maxHP: maxHP,
maxStress: maxStress,
evasion: evasion,
Expand Down
144 changes: 142 additions & 2 deletions Tests/DHModelsTests/ModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -294,17 +294,36 @@ struct EncounterDefinitionTests {

@Test func playerConfigCodableRoundTrip() throws {
let config = PlayerConfig(
name: "Sera", maxHP: 8, maxStress: 6,
name: "Sera", level: 3, maxHP: 8, maxStress: 6,
evasion: 14, thresholdMajor: 10, thresholdSevere: 18, armorSlots: 4
)
let data = try JSONEncoder().encode(config)
let decoded = try JSONDecoder().decode(PlayerConfig.self, from: data)

#expect(decoded.name == "Sera")
#expect(decoded.level == 3)
#expect(decoded.maxHP == 8)
#expect(decoded.evasion == 14)
#expect(decoded.armorSlots == 4)
}

@Test func playerConfigLevelDefaultsToOneWhenAbsentInJSON() throws {
let json = try #require(
"""
{
"id": "00000000-0000-0000-0000-000000000002",
"name": "Legacy",
"maxHP": 6,
"maxStress": 6,
"evasion": 12,
"thresholdMajor": 8,
"thresholdSevere": 15,
"armorSlots": 3
}
""".data(using: .utf8))
let decoded = try JSONDecoder().decode(PlayerConfig.self, from: json)
#expect(decoded.level == 1)
}
}

// MARK: - DifficultyBudget
Expand Down Expand Up @@ -440,6 +459,90 @@ struct DifficultyBudgetTests {
#expect(DifficultyBudget.Adjustment.noBigThreats.pointValue == 1)
#expect(DifficultyBudget.Adjustment.harderFight.pointValue == 2)
}

// MARK: Tier Utilities

@Test(arguments: zip(1...10, [1, 2, 2, 2, 3, 3, 3, 4, 4, 4]))
func tierForLevel(level: Int, expectedTier: Int) {
#expect(DifficultyBudget.tier(forLevel: level) == expectedTier)
}

@Test func partyTierSinglePlayer() {
#expect(DifficultyBudget.partyTier(levels: [1]) == 1)
#expect(DifficultyBudget.partyTier(levels: [5]) == 3)
#expect(DifficultyBudget.partyTier(levels: [9]) == 4)
}

@Test func partyTierEmptyIsT1() {
#expect(DifficultyBudget.partyTier(levels: []) == 1)
}

@Test func partyTierMedianBoundaryRoundsUp() {
// [2,3,4,5] → median 3.5 → ceil 4 → T2
#expect(DifficultyBudget.partyTier(levels: [2, 3, 4, 5]) == 2)
// [4,5] → median 4.5 → ceil 5 → T3
#expect(DifficultyBudget.partyTier(levels: [4, 5]) == 3)
}

@Test func partyTierOddCount() {
// [7,8,9] → median 8 → T4
#expect(DifficultyBudget.partyTier(levels: [7, 8, 9]) == 4)
}

// MARK: lowerTierAdversary auto-detection

@Test func lowerTierAdversaryDetectedWhenPresent() {
let adjustments = DifficultyBudget.suggestedAdjustments(
adversaryTypes: [.standard, .minion],
adversaryTiers: [3, 1],
partyTier: 3
)
#expect(adjustments.contains(.lowerTierAdversary))
}

@Test func lowerTierAdversaryNotDetectedWhenAllTiersMatch() {
let adjustments = DifficultyBudget.suggestedAdjustments(
adversaryTypes: [.standard, .minion],
adversaryTiers: [3, 3],
partyTier: 3
)
#expect(!adjustments.contains(.lowerTierAdversary))
}

@Test func lowerTierAdversaryNotDetectedWithoutPartyTier() {
let adjustments = DifficultyBudget.suggestedAdjustments(
adversaryTypes: [.standard],
adversaryTiers: [1],
partyTier: nil
)
#expect(!adjustments.contains(.lowerTierAdversary))
}

@Test func lowerTierAdversaryIgnoresUnknownTierZero() {
let adjustments = DifficultyBudget.suggestedAdjustments(
adversaryTypes: [.standard],
adversaryTiers: [0],
partyTier: 3
)
#expect(!adjustments.contains(.lowerTierAdversary))
}

@Test func lowerTierAdversaryNotDetectedWithEmptyRoster() {
// adversaryTiers has a lower-tier entry but adversaryTypes is empty —
// zip silences the dangling tier, so no adjustment should fire.
let adjustments = DifficultyBudget.suggestedAdjustments(
adversaryTypes: [],
adversaryTiers: [1],
partyTier: 3
)
#expect(!adjustments.contains(.lowerTierAdversary))
}

@Test func suggestedAdjustmentsBackwardCompatible() {
// Calling with no tier params must still detect multipleSolos / noBigThreats.
let adjustments = DifficultyBudget.suggestedAdjustments(adversaryTypes: [.solo, .solo])
#expect(adjustments.contains(.multipleSolos))
}
}

// MARK: - Player
Expand Down Expand Up @@ -474,15 +577,52 @@ struct PlayerTests {
#expect(decoded.armorSlots == 4)
}

@Test func playerDefaultLevelIsOne() {
let player = Player(
name: "Aldric", maxHP: 6, maxStress: 6,
evasion: 12, thresholdMajor: 8, thresholdSevere: 15, armorSlots: 3
)
#expect(player.level == 1)
}

@Test func playerLevelRoundTrip() throws {
let player = Player(
name: "Sera", level: 5, maxHP: 8, maxStress: 6,
evasion: 14, thresholdMajor: 10, thresholdSevere: 18, armorSlots: 4
)
let data = try JSONEncoder().encode(player)
let decoded = try JSONDecoder().decode(Player.self, from: data)
#expect(decoded.level == 5)
}

@Test func playerLevelDefaultsToOneWhenAbsentInJSON() throws {
let json = try #require(
"""
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Legacy",
"maxHP": 6,
"maxStress": 6,
"evasion": 12,
"thresholdMajor": 8,
"thresholdSevere": 15,
"armorSlots": 3
}
""".data(using: .utf8))
let decoded = try JSONDecoder().decode(Player.self, from: json)
#expect(decoded.level == 1)
}

@Test func asConfigPreservesAllFields() {
let player = Player(
name: "Torven", maxHP: 10, maxStress: 5,
name: "Torven", level: 4, maxHP: 10, maxStress: 5,
evasion: 11, thresholdMajor: 7, thresholdSevere: 14, armorSlots: 2
)
let config = player.asConfig()

#expect(config.id == player.id)
#expect(config.name == player.name)
#expect(config.level == player.level)
#expect(config.maxHP == player.maxHP)
#expect(config.maxStress == player.maxStress)
#expect(config.evasion == player.evasion)
Expand Down
Loading